🇨🇳 中文(面试标准答案)
🇺🇸 English (interview version)
🇨🇳 中文口语版
👉 举例:
🇺🇸 English
理解这一点至关重要,因为无论是防抖还是节流,都会用到它。
我原来的理解是:fn.apply(this, args) ,只是fn绑定了指定的this和args,并没有执行。
这个理解是错的,所以防抖和节流的代码老是无法理解清楚。
你的理解其实只对了一半!你注意到了它在绑定 this 和传递参数,但实际上,apply 不仅会绑定,还会立刻执行函数。
在 JavaScript 中,真正「只绑定、不执行」的方法是 bind,而 apply 和 call 是会立即调用的。
apply 的真正行为:绑定 + 立即执行我们可以把 fn.apply(this, args) 拆解为两个动作同时发生:
this 指向):把函数内部的 this 指向你传进去的第一个参数。args 数组拆开作为参数传进去,然后瞬间执行这个函数。function greet(greeting, punctuation) { console.log(greeting + ', ' + this.name + punctuation);}const person = { name: '张三' };// 执行这一行时,greet 会【立刻】被调用并打印结果greet.apply(person, ['你好', '!']); // 输出: "你好,张三!"
apply?如果你直接写 fn(...args) 也能执行,但为什么要大费周章地写 fn.apply(this, args) 呢?
这是为了完美冒充。
防抖和节流函数是作为「代理人」包裹在原本的事件函数外面的。为了让原本的函数(fn)感觉自己就像是在原生事件里被直接触发的一样,必须保证它的 this 上下文和参数不丢失。
假设我们在网页上监听一个按钮的点击:
const btn = document.querySelector('button');btn.addEventListener('click', debounce(function(e) { console.log(this); // 如果不用 apply,这里的 this 会变成 window! console.log(e); // 如果不用 args,这里的点击事件对象 e 就丢失了!}, 1000));在原生的点击事件中:
this 本应该指向这个 <button> 标签。MouseEvent(事件对象 e)作为参数。apply 的惨剧:如果我们直接在防抖内部调用 fn():
// 错误/不完美的写法timer = setTimeout(() => { fn(); // 💥 此时内部的 this 丢失了,变成了默认的 window,参数 e 也没了}, delay);apply 后的完美还原:// 正确的写法return function(args) { // 这里的 ...args 接收到了浏览器传进来的 [MouseEvent] if (timer) clearTimeout(timer); timer = setTimeout(() => { // 这里的 this,就是闭包外层保存的 `<button>` 元素 // 这里的 args,就是外层接收到的点击事件数组 fn.apply(this, args); // 完美复活!执行 fn,并且 this 是 button,参数是 e }, delay);};fn.apply(this, args) 是会立即执行函数的。this(比如 DOM 元素)和 arguments(比如浏览器传入的 Event 对象)原封不动地传给真正要执行的函数,防止上下文丢失。
防抖和节流,整体函数是什么意思,看到定义就知道了。但是我卡住的地方就在于,我不理解里面的fn到底是怎么执行的。闭包 + 高级函数 + setTimeout,我的大脑就是转不动。
你的直觉非常敏锐!你问到了 JavaScript 中最核心、最经典,也最让人头疼的一个概念——闭包(Closure)。
你说的没错:“如果 timer 是函数内部定义的变量,函数执行完不就被销毁了吗?为什么第二次点击还能访问到上一次的 timer?”
我们用两张内存结构图来彻底看清 timer 到底藏在哪里。
如果你把代码写成这样,确实会像你担忧的那样,每次点击 timer 都会被重置:
// ❌ 错误示范:如果每次点击都完整执行这个函数function clickHandler() { let timer = null; // 每次点击,都会重新声明一个 timer = null if (timer) clearTimeout(timer); // 此时 timer 永远是 null,if 永远不成立 timer = setTimeout();}xxxxxxxxxx<button onClick={clickHandler}>Search</button>
但在防抖的实现里,我们用了一个嵌套函数。
xxxxxxxxxx<button onClick={myClick}>Search</button>当你执行 const myClick = debounce(fn, 1000) 时,debounce 函数只执行了一次。
看下面这张图,它展示了当你初始化防抖和连续点击时,内存里到底发生了什么:
【第一步:初始化绑定 (只执行一次)】const myClick = debounce(fn, 1000);内存中生成了一个神秘的空间(闭包):┌────────────────────────────────────────┐│ debounce 的作用域 (这块内存不会被销毁) ││ let timer = null; <───┐ │└─────────────────────────┼──────────────┘ │ (依然保持引用)┌─────────────────────────┴──────────────┐│ 返回出来的匿名函数 (赋值给了 myClick) ││ function(...args) { ... } │└────────────────────────────────────────┘【第二步:用户第一次点击 (触发 myClick)】1. 进入匿名函数。2. 问:“闭包里的 timer 有值吗?” ──> 答:“是 null”。3. 跳过 if 块。4. 执行 timer = setTimeout(...)。5. 此时,闭包里的 timer 变成了 [定时器ID_1]。┌────────────────────────────────────────┐│ 闭包空间 ││ let timer = [定时器ID_1]; (被修改了) │└────────────────────────────────────────┘【第三步:用户第二次点击 (再次触发 myClick)】1. 再次进入匿名函数。2. 问:“闭包里的 timer 有值吗?” ──> 答:“有!是 [定时器ID_1]”。3. if(timer) 条件成立!4. 执行 clearTimeout([定时器ID_1]) ──> 成功干掉第一个定时器!5. 执行 timer = setTimeout(...),将闭包里的 timer 更新为 [定时器ID_2]。timer 存在哪里myClick 这个点击事件函数,它外层的这个“温室”就永远不会被浏览器垃圾回收机制销毁。let timer 变量,就像是一个全局变量一样被持久保存着,但神奇的是,外面任何人都无法访问和修改它,只有你的防抖函数内部可以访问。所以,第二次及以后点击时,if(timer) 判断的不是一个新声明的变量,而是那个一直住在“温室”里、记录着上一次定时器状态的同一个 timer 变量。
其实就是 setTimeout + 用户不断触发时 这个过程我不是很理解。
我完全理解了!你的纠结卡在了一个核心盲点上:“我手在点,代码在跑,定时器在转,到底谁在什么时候把真正的 fn 推出去执行了?”
我们把外层的包裹函数比作“传达室保安”,把真实的 fn 比作“办业务的客人”。我们再来看一次最细致的执行轨迹。
假设 delay 是 2秒。
我们在第 0 秒点第一次,第 1 秒点第二次,然后停手。
时间轴 (秒) 0s ------------- 1s ------------- 2s ------------- 3s ---->用户动作: 👆点击(第1次) 👆点击(第2次) (静止不动...) (静止不动...)if (timer) clearTimeout(timer); 执行 —— 此时 timer 是 null,啥也没清。timer = setTimeout(...) 执行。注意!此时内层的 fn.apply 只是被打包塞进了定时器,它并没有执行,它在闭合的闹钟里“沉睡”。 闹钟定在 2 秒后(即第 2 秒)。if (timer) clearTimeout(timer); 启动!这个动作瞬间把第 0 秒定下的那个闹钟砸碎了。 原来在里面沉睡的 fn 直接被销毁,根本没机会执行。timer = setTimeout(...) 再次执行,重新打包一个 fn.apply 塞进一个新闹钟。这个新闹钟定在 2 秒后(即第 3 秒)。fn.apply(this, args) 执行了!fn 真正开始跑它自己的业务逻辑(比如发送搜索请求)。还是以时间戳版为例,假设 delay(CD时间)是 2秒。
用户在第 0 秒点击,在第 1 秒拼命狂点,第 2 秒又点。
时间轴 (秒) 0s ------------- 1s ------------- 2s ------------- 3s ---->用户动作: 👆点击(第1次) 👆👆👆狂点 👆点击lastTime: 0s 0s 0s 2snow: 0s 1s 2s ...lastTime = 0。now = Date.now()。now - lastTime (1784079026486- 0 = 1784079026486) delay (2000)。条件成立!(此时必然成立)。fn.apply(this, args)。fn 在第 0 秒直接被调用。lastTime = now,也就是说 lastTime 变成了 1784079026486。now = 1784079026486。now - lastTime (1784079027491 - 1784079026486 = 1005) delay (2000)。条件不成立!if 块,直接结束。 内层的 fn.apply 连露脸的机会都没有,完全被无视了。now = 1784079028497。now - lastTime (1784079028497 - 1784079026486 = 2011) delay (2000)。条件刚好成立!fn.apply(this, args)。fn 第二次被成功调用。lastTime = 1784079028497,下一轮 2 秒 重新开始。fn:是一个“死里逃生”的过程。它每被安排一次(setTimeout),都要经历一段等待期。如果在等待期内又有新安排,旧安排就被“谋杀”(clearTimeout)。只有在无人打扰的寂静期结束时,fn 才能顺利存活并执行。fn:是一个“查门票”的过程。每次触发,代码都会去算距离上次放行过去了多久。时间够了,就把 fn 放过去执行,并把大门关上(更新时间);时间不够,就把 fn 拦在 if 外面,直接丢弃。这么拆开来看每一秒发生的事情,有没有感觉逻辑线连上了?
重点理解:
看似用户多次触发会产生很多 fn 在“计划表”中(比如说 2000ms 之后把fn推入执行队列),好像很多 fn 都会依次执行,但其实只有真正的 fn 才会执行,其余的 fn 会随着 timer 的注销而注销。这一点真的是重点。
clearTimeout(timer),我没有真正理解其含义看上去只是清理一个延时函数,但其实里面做了很多事情,其中最重要的就是将延时函数里面的 fn 注销掉了。
clearTimeout(timer) 会连根拔起,直接把还没来得及执行的 fn 彻底注销掉。
为了让你完全放心,我们来看看浏览器底层到底是怎么管理这个过程的:
setTimeout 到底干了什么?当你执行 timer = setTimeout(fn, 2000) 时,JS 引擎其实做了两件事:
fn 推入执行队列。”1、2、3),这个 ID 就是那张计划表里的行号(也就是你的 timer 变量拿到的值)。clearTimeout(timer) 的注销动作当你在第二次点击触发 clearTimeout(timer) 时,你其实是拿着这个行号对浏览器说:“把计划表里第 timer 行的任务给我擦掉!”
此时,浏览器会立刻去后台:
fn,直接从内存和计划表中消失了,它永远不会被推入执行队列,更不会被执行。你可以把 timer = setTimeout(fn, 2000) 理解为“点外卖”:
timer)。delay)把外卖(fn)送到你家。当你连续点击,触发 clearTimeout(timer) 时,就像是你突然“取消了订单”:
timer)点击了退单。fn)根本不会送过来,你什么都吃不到。然后防抖函数又紧接着帮你下了一个新订单,重新开始等 30 分钟。所以,老的那次 fn 是被真正地、彻底地注销了,绝对不会在暗地里悄悄执行。
xxxxxxxxxxfunction debounce(fn, delay = 300) { let timer = null; return function (args) { clearTimeout(timer); timer = setTimeout(() => { fn.apply(this, args); }, delay); };}🇨🇳:
核心就是用 setTimeout + clearTimeout,每次触发都清掉上一次的定时器,只保留最后一次。
🇺🇸:
We use setTimeout and cancel previous timers with clearTimeout to ensure only the last call is executed.
xxxxxxxxxxfunction throttle(fn, delay = 300) { let lastTime = 0; return function (args) { const now = Date.now(); if (now - lastTime >= delay) { lastTime = now; fn.apply(this, args); } };}xxxxxxxxxxfunction throttle(fn, delay = 300) { let timer = null; return function (args) { if (timer) return; timer = setTimeout(() => { fn.apply(this, args); timer = null; }, delay); };}🇨🇳: 节流就是“规定时间内只允许执行一次”,要么用时间戳控制,要么用定时器锁住。
🇺🇸: Throttle ensures the function runs only once per time interval using either timestamps or a timer lock.
👉 可以(leading debounce)
xxxxxxxxxxfunction debounce(fn, delay, immediate = false) { let timer = null; return function (args) { const callNow = immediate && !timer; clearTimeout(timer); // clearTimeout这里的参数,如果是 null 或者 undefined,都是安全的,不会报错。所以加上或者不加上 if(timer) 都可以 timer = setTimeout(() => { timer = null; }, delay); if (callNow) fn.apply(this, args); };}这里的 setTimeout 里面,是不是缺少这句代码
fn.apply(this, args)?刚开始,timer为null,fn会立即执行第一次,只有等到delay时间之后触发,timer在delay时间之后被设置为null,解锁,让下一次调用可以再次立即执行。
立即执行第一次的debounce和之前例子的debounce执行是不同的:
举个按钮例子
代码:
xxxxxxxxxxconst submit = debounce(() => {console.log("submit");},1000,true);用户:
xxxxxxxxxxclickclickclickclick时间:
xxxxxxxxxx0ms 第一次clickfn执行 ✅200ms 第二次clicktimer存在不执行 ❌400ms 第三次click不执行 ❌800ms 第四次click不执行 ❌1000mstimer=null最终:
xxxxxxxxxxsubmit只打印一次。
两者对比
Leading debounce(立即执行)
xxxxxxxxxx第一次触发↓立即 fn()↓等待 delay↓解锁代码:
xxxxxxxxxxif(callNow){fn()}Trailing debounce(延迟执行)
这个就是我们之前实现的代码。
xxxxxxxxxx第一次触发↓启动timer↓不断重置timer↓停止触发↓delay后执行fn()代码:
xxxxxxxxxxsetTimeout(()=>{fn()},delay)
可以(trailing throttle),但需要稍微升级实现(面试加分点)
| 场景 | 技术 |
|---|---|
| 输入搜索 | 防抖 |
| resize | 节流 |
| scroll监听 | 节流 |
| 按钮点击防重复提交 | 防抖 |
| 游戏按键 | 节流 |
🇨🇳 中文
🇺🇸 English
可以这样说:
防抖适合“最终状态”,节流适合“过程控制”
下面实现的
xxxxxxxxxxuseDebounce(value, delay)useThrottle(value, delay)主要用于“值(value)的防抖 / 节流”。
而我们最开始讲的 JavaScript:
xxxxxxxxxxdebounce(fn, delay)throttle(fn, delay)是用于函数(callback)的防抖 / 节流。
但其实函数的防抖和节流,在项目中还是很常见的,所以这种hooks也是很常见的:
useDebounceCallback、useThrottleCallback。比如说 window resize 时,需要加上防抖。
xxxxxxxxxxconst handleResize = useDebouncedCallback(()=>{console.log(window.innerWidth)},500)实现也很简单,像js那样实现即可。
setTimeout 延迟更新xxxxxxxxxximport { useEffect, useState } from "react";export function useDebounce(value, delay = 300) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timer = setTimeout(() => { setDebouncedValue(value); }, delay); return () => { clearTimeout(timer); }; }, [value, delay]); return debouncedValue;}🇨🇳: 每次 value 变化时我不会立即更新,而是启动一个定时器。如果在 delay 时间内 value 又变化了,就清除上一次的定时器,只保留最后一次更新。
🇺🇸: It delays updating the state until the value stops changing for a given delay.
xxxxxxxxxxconst [input, setInput] = useState("");const debouncedInput = useDebounce(input, 500);useEffect(() => { // 只在“停下来之后”触发 fetch(`/api/search?q=${debouncedInput}`);}, [debouncedInput]);
lastTime 或 timer 控制xxxxxxxxxximport { useEffect, useRef, useState } from "react";export function useThrottle(value, delay = 300) { const [throttledValue, setThrottledValue] = useState(value); const lastTime = useRef(0); useEffect(() => { const now = Date.now(); if (now - lastTime.current >= delay) { lastTime.current = now; setThrottledValue(value); } }, [value, delay]); return throttledValue;}xxxxxxxxxximport { useEffect, useRef, useState } from "react";export function useThrottle(value, delay = 300) { const [throttledValue, setThrottledValue] = useState(value); const timer = useRef(null); useEffect(() => { if (timer.current) return; timer.current = setTimeout(() => { setThrottledValue(value); timer.current = null; }, delay); }, [value, delay]); return throttledValue;}🇨🇳: 节流就是控制更新频率,在一定时间内只允许执行一次 state 更新。
🇺🇸: Throttle limits how often state can be updated during continuous changes.
面试官经常追问👇
👉 答:
🇨🇳: 因为 useRef 不会触发重新渲染,而且可以跨 render 保持同一个值。
🇺🇸: useRef keeps a stable mutable value across renders without causing re-renders.
| 维度 | useDebounce | useThrottle |
|---|---|---|
| 触发方式 | 停止后执行 | 按间隔执行 |
| 场景 | 搜索 | 滚动 |
| 控制方式 | 清定时器 | 限制频率 |
👉 可以升级(高级面试题):
xxxxxxxxxxexport function useDebouncedCallback(fn, delay = 300) { const timer = useRef(null); return (args) => { clearTimeout(timer.current); timer.current = setTimeout(() => { fn(args); }, delay); };}
🇨🇳 中文
🇺🇸 English